由于大多数地方都有高速、低延迟(low-latency)的互联网,我们很容易忘记,并不是所有用户在使用我们的应用程序时都在快速、低延迟的上行链路上。你甚至不用去偏远的地方就能体验到不稳定的网络连接。我住在德国汉堡,尽管高速移动互联网无处不在,但在一些主要的地面交通线路上,有相当多的地方没有或非常糟糕的连接。
在构建访问互联网的应用程序时,我们应该注意这一点,并确保我们不会浪费带宽。
在本系列的这一部分中,我想重点讨论如何在使用Combine时优化应用程序中的网络访问。
Previously…
上次,我们创建了一个Combine
管道,用于检查用户选择的用户名的可用性,并将该管道连接到一个用SwiftUI
编写的简单登录表单。
当运行应用程序并检查测试服务器的日志时,我们注意到isUserNameAvailable
端点对于键入的每个字符被调用多次。这显然是不理想的:它不仅浪费了我们服务器上的CPU周期(这可能会成为一个问题,如果你托管你的服务器与云计算提供商按调用次数或CPU正常运行时间收费);这也意味着我们给应用程序增加了额外的网络开销。
在本地运行测试服务器时,您可能不会注意到这一点,但是当您在Edge
连接上与服务器的远程实例通信时,您会注意到这一点。
如果您的API
端点不是幂等的,问题会变得更糟:想象一下调用API
端点来预订座位或购买音乐会门票。通过发送两个(或更多)请求而不是一个请求,您最终会预订比您需要的更多的座位,或者购买比您想要的更多的音乐会门票。
那么,我们怎么解决这个问题呢?
Identifying the root cause
首先,我们需要找出是什么导致了这些额外的请求。
要弄清楚Combine
管道发生了什么,一种简单的方法是添加一些调试代码。让我们将print()
操作符添加到管道中:
1 | private lazy var isUsernameAvailablePublisher: AnyPublisher<Bool, Never> = { |
这个操作符将一些有用的东西记录到控制台:
- 管道的任何生命周期事件(例如,正在添加订阅)
- 正在发送/接收的任何值
我们可以指定一个前缀(“username”),以使日志语句在控制台上突出。
再次运行应用程序,我们立即看到以下输出-即使没有在文本字段中输入任何内容:
1 | username: receive subscription: (PublishedSubject) |
这表明我们的管道有两个订阅者!
看看我们的代码,我们可以在视图模型的初始化器中发现这些订阅者:
1 | init() { |
第一个订阅者是提供isValid
属性的管道,我们最终使用该属性启用/禁用登录表单上的提交按钮。
第二个订阅者是在所选用户名不可用时生成错误消息的管道。该管道的结果也将显示在登录表单上。
现在我们已经确定了导致多个订阅到我们的发布者的原因,让我们看看我们可以做些什么来只使用一个订阅。
Using the share operator to share a publisher
为单个发布者提供多个订阅者是一种常见模式,特别是在UI中,其中单个UI元素可能会对多个其他元素产生影响。
如果需要与多个订阅者共享发布者的结果,可以使用share()
操作符。根据苹果的文件:
The publisher returned by this operator supports multiple subscribers, all of whom receive unchanged elements and completion states from the upstream publisher.
此操作符返回的发布者支持多个订阅者,所有订阅者都从上游发布者接收未更改的元素和完成状态。
这正是我们需要的。通过将share
操作符应用到isUsernameAvailablePublisher
的管道末端,我们将每个事件(即用户在用户名输入字段中输入的每个字符)的管道结果与发布者的所有订阅者共享:
1 | private lazy var isUsernameAvailablePublisher: AnyPublisher<Bool, Never> = { |
当运行更新后的代码时,我们可以看到$username
发布者不再有两个订阅者,而是只有一个:
1 | username: receive subscription: (PublishedSubject) |
现在,您可能想知道为什么只有一个订阅者,因为我们显然仍然有两个已发布的属性(isValid
和usernameMessage
)订阅了管道。
答案很简单:share
操作符最终是这一个订阅者,而它又被isValid
和isUsernameAvailablePublisher
订阅。为了证明这一点,让我们在管道中添加另一个print()
操作符:
1 | private lazy var isUsernameAvailablePublisher: AnyPublisher<Bool, Never> = { |
在结果输出中,我们可以看到share
收到两个订阅(1、2),而username
只有一个订阅(3):
1 | share: receive subscription: (Multicast) 1 |
您可以将share()
看作一个分叉,它从上游发布者那里接收事件,并将它们广播给所有订阅者。
这是一个bug还是一个特性?
继续并在用户名字段中键入几个字符,您将发现,对于您键入的每个字符,您仍然会看到向服务器发出两个请求。这可能是iOS 15中的一个问题——我调试了一下这个问题,似乎TextField每次击键都会发出两次。在之前的iOS版本中,情况并非如此,我倾向于认为这是iOS 15的一个bug,所以我创建了一个示例项目来重现这个问题(见AppleFeedback/FB9826727在main·peterfriese/AppleFeedback),并向苹果提交了一个反馈(FB9826727)。如果你同意我的观点,认为这是一种倒退,那么也可以考虑提交一个反馈——一个bug收到的重复越多,它就越有可能被解决。
Using debounce to further optimise the UX
在构建与远程系统通信的ui时,我们需要记住,用户输入的速度通常比系统提供反馈的速度快得多。
例如,在选择用户名时,我通常会键入我最喜欢的用户名,而不会在单词中间停下来输入。我不在乎这个用户名的前几个字母是否可用-我对全名感兴趣。在每次击键后将不完整的用户名发送到服务器没有多大意义,而且看起来很浪费。
为了避免这种情况,我们可以使用Combine的debounce
操作符:它将丢弃所有事件,直到出现暂停。然后,它将把最近的事件传递给下游发布者:
1 | private lazy var isUsernameAvailablePublisher: AnyPublisher<Bool, Never> = { |
通过这样做,我们告诉Combine
忽略对username
的所有更新,直到出现0.8
秒的暂停,并将最新的username
发送给管道上的下一个操作符(在本例中是print操作符,然后将未更改的事件传递给flatMap
操作符)。
这更适合正常的用户输入行为,并将导致应用程序向服务器发送更少的请求。
Using removeDuplicates to avoid sending the same request twice
你是否曾经和一个人说话,并问他们同样的问题两次?这是一个有点尴尬的情况,对方可能会怀疑你是否一直在关注他们。
现在,尽管人工智能正在取得进步,但我确信计算机没有情感,所以如果你两次发送相同的API请求,它们不会怀恨在心。但是,为了给我们的用户最好的体验,我们应该尽量避免发送重复的请求。
Combine有一个操作符:removeduplates
——它将从事件流中删除任何重复的事件,如果它们随后彼此跟随。
这与debounce
操作符结合使用效果非常好,我们可以将这两个操作符结合使用(对不起,我想你将不得不忍受双关语),以进一步优化我们的用户名可用性检查:
1 | private lazy var isUsernameAvailablePublisher: AnyPublisher<Bool, Never> = { |
总之,它们将进一步减少我们发送到服务器的请求数量,以防用户输入错误,然后纠正他们的拼写。
让我们来看一个例子:
Jonyive[停顿]s[退格]
这将发送以下请求:
- jonyive
- 没有请求jonyives(因为在debounce超时之前s被删除了)
- 没有对jonyive的第二次请求,因为它被removeduplicate过滤了
这可能是一件小事,但每一件小事都有帮助。
Closure
在本文中,我们讨论了Combine
如何提高与远程服务器(实际上是任何异步API)的通信效率的多种方法。
通过使用Share
操作符,我们可以将多个订阅者附加到发布者/管道,并避免为每个订阅者运行昂贵/耗时的处理。这在访问比进程内模块具有更高延迟的api时特别有用,例如远程服务器或任何涉及I/O
的api
。
debounce
操作符允许我们更有效地处理短时间内发生的任何事件,比如用户输入。我们不是处理管道中的每个事件,而是等待暂停并只操作最近的事件。
为了避免处理重复事件,我们可以使用removeduplicate
操作符。顾名思义,它删除任何直接后续的重复事件,例如当我们还使用debounce
操作符时,用户添加然后删除一个字符。
总之,这些操作符可以帮助我们构建以更有效的方式访问远程服务器和其他异步api的客户端。
在本系列的下一集中,我们将探讨错误处理的主题,以及如何使用Combine处理与状态相关的服务器响应。
感谢阅读🔥